Skip to content

Throttled progress/cancel callback for load_obj_buf - #79

Open
virtualritz wants to merge 3 commits into
Twinklebear:masterfrom
virtualritz:pr/progress-callback
Open

Throttled progress/cancel callback for load_obj_buf#79
virtualritz wants to merge 3 commits into
Twinklebear:masterfrom
virtualritz:pr/progress-callback

Conversation

@virtualritz

Copy link
Copy Markdown
Contributor

Summary

  • LoadOptions gains progress_callback: Option<LoadProgressCallback> (Arc<dyn Fn(&LoadProgress) -> ControlFlow<()> + Send + Sync>), an options field rather than a second _with_progress function, so a caller can observe throttled progress and cooperatively cancel a load of a large OBJ buffer.
  • Invoked every 1000 lines inside load_obj_buf's parse loop. Returning ControlFlow::Break stops the load and returns the new LoadError::Cancelled.
  • No behavior change when progress_callback is None (the default): verified by a test comparing output with and without a no-op callback.
  • load_obj_buf_async (the futures/tokio variants) is intentionally untouched — that's a materially different code path deserving its own design pass.
  • LoadOptions loses its Copy derive (Arc isn't Copy); every existing call site in this crate already passes &LoadOptions, so nothing else needed to change.

Motivated by a real use case: an interactive mesh-editing tool importing large OBJ files needed a way to show progress and let a user cancel a slow import without the caller being able to observe anything until the whole call returned.

Test plan

  • cargo test --all-features: 21 lib tests + 5 doctests pass, including 3 new tests (test_progress_callback_noop_matches_no_callback, test_progress_callback_cancels_load, test_progress_callback_is_throttled)
  • cargo fmt --all -- --check: clean
  • cargo clippy --all-targets --all-features -- -D warnings: clean (exit 0)

LoadOptions gains progress_callback: Option<LoadProgressCallback>
(Arc<dyn Fn(&LoadProgress) -> ControlFlow<()> + Send + Sync>, an options
field rather than a second _with_progress function), invoked every 1000
lines during load_obj_buf's parse loop. Returning ControlFlow::Break stops
the load and returns the new LoadError::Cancelled. No behavior change when
progress_callback is None (the default): verified by a test comparing
output with and without a no-op callback. load_obj_buf_async is untouched.
Comment thread src/lib.rs
}

if let Some(callback) = &load_options.progress_callback {
if lines_read.is_multiple_of(PROGRESS_REPORT_INTERVAL) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like is_multiple_of is a relatively recent Rust function https://doc.rust-lang.org/std/primitive.u64.html#method.is_multiple_of , can we instead just use PROGRESS_REPORT_INTERVAL > 0 && lines_read % PROGRESS_REPORT_INTERVAL == 0 ? to guard against prog report interval being 0 ? It looks like that's the main difference of not using % directly, the is_multiple_of doesn't panic of the rhs is 0

@virtualritz virtualritz Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapping to the % form failed clippy's own manual_is_multiple_of lint under -D warnings (the exact check in this PR's own test plan).
Since PROGRESS_REPORT_INTERVAL is a fixed const = 1000 it can never actually be 0 either way, and the method has been stable since 1.87 with nothing here pinning an older toolchain, so I kept is_multiple_of.
Happy to add #[allow(clippy::manual_is_multiple_of)] and go back to the explicit guard if you'd still rather have that for other reasons.

Comment thread src/lib.rs
return Err(LoadError::Cancelled);
}
}
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we always fire the callback on completion?

@virtualritz virtualritz Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an unconditional call after the loop with the true final lines_read/bytes_read, so a progress UI driven off this callback reaches 100% instead of inferring "done" from the Ok return (lines_read only lands on a PROGRESS_REPORT_INTERVAL boundary by chance).
I made ControlFlow::Break on that final call a no-op.
The parse has fully succeeded by then, so there's nothing left to cancel, and discarding a complete result over a stray Break on the terminal ping seemed like the wrong default.
Let me know if you'd rather it be honored uniformly.
Covered by two new tests: test_progress_callback_fires_once_more_on_completion_with_the_true_final_count and test_progress_callback_completion_call_cannot_cancel_an_already_finished_load.

lines_read only lands on a PROGRESS_REPORT_INTERVAL boundary by chance,
so a caller driving a progress bar purely off the callback could get
stuck short of 100% even though the load succeeded. ControlFlow::Break
on this final call is not honored -- the parse has already fully
succeeded by then, so there is nothing left to cancel.
check_fmt uses dtolnay/rust-toolchain@nightly, unpinned. Nightly
rustfmt's comment-wrap heuristic changed between this PR's original
commit (green on 2026-08-25) and now, so CI started flagging pre-existing
comment wrapping this PR never touched. Comment rewrap only, no
functional change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants